iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Modern Web

30 天 Rails 新手村:從工作專案學會 Ruby on Rails系列 第 10

Day 9: 認證系統實作 - 從零打造 JWT 認證的完整旅程

  • 分享至 

  • xImage
  •  

一、開場:從經驗出發

如果你來自 Node.js 的世界,你可能已經用過 Passport.js 或 jsonwebtoken。在 Express 中,你會手動組合中介軟體,精心管理 token 的每個生命週期。如果你來自 Spring Boot,你習慣了 Spring Security 的註解式配置,@PreAuthorize 和 @Secured 讓權限控制看起來優雅而簡潔。Python 的 FastAPI 則用依賴注入系統,讓認證邏輯像積木般組合。

今天我們要探討的是 Rails 如何處理認證這個永恆的話題。有趣的是,Rails 沒有內建的認證系統。這不是疏忽,而是刻意的設計決策。Rails 相信認證是如此重要且多樣化的需求,不應該用單一方案限制開發者的選擇。但這不代表你要從零開始,Rails 提供了所有必要的基礎設施,讓實作認證變得直觀而安全。

在我們即將建構的 LMS 系統中,認證是整個系統的基石。學生需要登入才能觀看課程,講師需要驗證身份才能上傳教材,管理員需要特殊權限才能管理系統。今天我們不只要實作一個能用的認證系統,更要建立一個能支撐複雜業務邏輯、易於擴展、安全可靠的認證架構。

二、概念探索:理解「為什麼」

2.1 JWT vs Session:Rails 的視角

Rails 誕生於 Web 2.0 時代,當時的應用主要是伺服器端渲染,Session 是最自然的選擇。Rails 的 session 機制設計得極其優雅,你只需要寫 session[:user_id] = user.id,Rails 會處理所有的細節:加密、簽名、Cookie 管理。

但在 API 模式下,情況變得複雜了。前後端分離意味著可能有多個客戶端:網頁、手機 App、第三方整合。Session 依賴 Cookie,而 Cookie 在跨域請求中會遇到各種限制。這就是為什麼 JWT(JSON Web Token)成為了 API 認證的主流選擇。

讓我們深入比較這兩種方案在 Rails 中的實作:

維度 Session-based JWT-based
狀態管理 伺服器端保存狀態 無狀態,資訊包含在 token 中
擴展性 需要 session store(Redis/資料庫) 不需要額外儲存
撤銷機制 簡單,刪除 session 即可 複雜,需要黑名單機制
資訊攜帶 只存 ID,其他資訊需查詢 可攜帶使用者資訊
安全考量 CSRF 攻擊風險 XSS 攻擊風險
Rails 支援 原生支援,零配置 需要自行實作或使用 gem

2.2 JWT 的內部結構與安全機制

JWT 不只是一個 token,它是一個自包含的安全憑證。理解它的結構對於正確使用至關重要:

# JWT 的三個部分:Header.Payload.Signature
# 
# Header: 描述 token 類型和簽名演算法
# {
#   "alg": "HS256",
#   "typ": "JWT"
# }
#
# Payload: 攜帶的資訊(claims)
# {
#   "sub": "1234567890",  # subject - 使用者 ID
#   "exp": 1516239022,    # expiration - 過期時間
#   "iat": 1516238022,    # issued at - 簽發時間
#   "role": "student"     # 自定義資訊
# }
#
# Signature: 確保 token 未被竄改
# HMACSHA256(
#   base64UrlEncode(header) + "." +
#   base64UrlEncode(payload),
#   secret
# )

Rails 的設計哲學告訴我們:安全不應該是事後的考量。當我們實作 JWT 時,需要考慮幾個關鍵的安全原則:

最小權限原則:Payload 中只放必要的資訊。不要為了減少資料庫查詢而放入敏感資料。

時間窗口控制:短期的 access token 配合長期的 refresh token,平衡安全性和使用者體驗。

密鑰管理:使用 Rails 的 credentials 系統管理密鑰,確保密鑰不會進入版本控制。

三、技術實作:掌握「怎麼做」

3.1 建立 JWT 服務類別

我們從最基礎的 JWT 編碼和解碼開始。Rails 鼓勵將業務邏輯封裝在服務物件中,這讓程式碼更容易測試和維護:

# app/services/jwt_service.rb
class JwtService
  # 使用 Rails 的密鑰管理系統
  # 在 credentials.yml.enc 中設定 secret_key_base
  SECRET_KEY = Rails.application.credentials.secret_key_base
  
  # Token 有效期設定
  ACCESS_TOKEN_EXPIRY = 15.minutes
  REFRESH_TOKEN_EXPIRY = 7.days
  
  class << self
    def encode(payload, exp = ACCESS_TOKEN_EXPIRY.from_now)
      # 加入標準 claims
      payload = payload.dup
      payload[:iat] = Time.current.to_i  # issued at
      payload[:exp] = exp.to_i           # expiration
      payload[:jti] = SecureRandom.uuid  # JWT ID,用於撤銷
      
      JWT.encode(payload, SECRET_KEY, 'HS256')
    end
    
    def decode(token)
      # 解碼並驗證 token
      decoded = JWT.decode(
        token, 
        SECRET_KEY, 
        true,  # 驗證簽名
        { 
          algorithm: 'HS256',
          verify_iat: true,  # 驗證簽發時間
          verify_exp: true   # 驗證過期時間
        }
      )
      
      # JWT.decode 返回陣列,第一個元素是 payload
      HashWithIndifferentAccess.new(decoded[0])
    rescue JWT::ExpiredSignature
      raise TokenExpiredError, '認證已過期,請重新登入'
    rescue JWT::InvalidIatError
      raise TokenInvalidError, '無效的認證時間'
    rescue JWT::DecodeError => e
      raise TokenInvalidError, "認證解析失敗:#{e.message}"
    end
    
    def refresh(token)
      # 解碼舊 token(即使已過期)
      decoded = JWT.decode(token, SECRET_KEY, true, { verify_exp: false })
      payload = decoded[0]
      
      # 檢查是否在可重新整理的時間窗口內
      issued_at = Time.at(payload['iat'])
      if issued_at < REFRESH_TOKEN_EXPIRY.ago
        raise TokenExpiredError, '認證已完全過期,請重新登入'
      end
      
      # 簽發新 token,保留原有資訊但更新時間
      new_payload = payload.except('iat', 'exp', 'jti')
      encode(new_payload)
    end
  end
end

# 自定義錯誤類別,讓錯誤處理更清晰
class TokenExpiredError < StandardError; end
class TokenInvalidError < StandardError; end

3.2 整合到 ApplicationController

接下來,我們需要將認證邏輯整合到控制器中。Rails 的 before_action 提供了優雅的方式來處理這類橫切關注點:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  # 引入必要的模組,即使是 API 模式
  include ActionController::HttpAuthentication::Token::ControllerMethods
  
  # 定義認證相關的 callbacks
  before_action :authenticate_request, if: :authentication_required?
  
  private
  
  def authenticate_request
    token = extract_token_from_header
    
    if token.present?
      begin
        @decoded_token = JwtService.decode(token)
        @current_user = User.find(@decoded_token[:user_id])
        
        # 檢查 token 是否被撤銷(使用 Redis 實作黑名單)
        if token_revoked?(@decoded_token[:jti])
          render_unauthorized('認證已被撤銷')
        end
      rescue TokenExpiredError => e
        # 嘗試自動重新整理 token
        handle_expired_token(token, e)
      rescue TokenInvalidError, ActiveRecord::RecordNotFound => e
        render_unauthorized(e.message)
      end
    else
      render_unauthorized('缺少認證資訊')
    end
  end
  
  def extract_token_from_header
    # 支援多種 token 傳遞方式
    # 1. Authorization: Bearer <token>
    authenticate_with_http_token { |token, _options| return token }
    
    # 2. 自定義 header: X-Auth-Token
    request.headers['X-Auth-Token']
  end
  
  def handle_expired_token(token, error)
    # 檢查是否有 refresh token(通常在另一個 header 中)
    refresh_token = request.headers['X-Refresh-Token']
    
    if refresh_token.present?
      begin
        new_token = JwtService.refresh(refresh_token)
        response.headers['X-New-Token'] = new_token
        
        # 繼續處理請求,使用新 token 的資訊
        @decoded_token = JwtService.decode(new_token)
        @current_user = User.find(@decoded_token[:user_id])
      rescue StandardError
        render_unauthorized(error.message)
      end
    else
      render_unauthorized(error.message)
    end
  end
  
  def token_revoked?(jti)
    # 使用 Redis 維護撤銷的 token 黑名單
    # 這是處理 JWT 無法主動撤銷的常見模式
    Rails.cache.exist?("revoked_token:#{jti}")
  end
  
  def authentication_required?
    # 預設所有請求都需要認證
    # 子類別可以覆寫這個方法來定義公開的端點
    true
  end
  
  def render_unauthorized(message = '未授權的請求')
    render json: { 
      error: message,
      code: 'UNAUTHORIZED'
    }, status: :unauthorized
  end
  
  # 提供給子類別使用的輔助方法
  attr_reader :current_user, :decoded_token
  
  def logged_in?
    current_user.present?
  end
end

3.3 實作認證端點

現在我們需要實作登入、登出和重新整理 token 的端點:

# app/controllers/api/v1/auth_controller.rb
module Api
  module V1
    class AuthController < ApplicationController
      # 登入和註冊不需要認證
      skip_before_action :authenticate_request, only: [:login, :register]
      
      def register
        user = User.new(user_params)
        
        if user.save
          # 註冊成功後自動登入
          tokens = generate_tokens(user)
          render_login_success(user, tokens)
        else
          render_validation_errors(user.errors)
        end
      end
      
      def login
        user = User.find_by(email: params[:email])
        
        if user&.authenticate(params[:password])
          # 記錄登入資訊
          user.update(
            last_login_at: Time.current,
            last_login_ip: request.remote_ip
          )
          
          tokens = generate_tokens(user)
          render_login_success(user, tokens)
        else
          # 避免透露帳號是否存在
          render_unauthorized('電子郵件或密碼錯誤')
        end
      end
      
      def logout
        # 將 token 加入黑名單
        jti = decoded_token[:jti]
        expires_at = Time.at(decoded_token[:exp])
        
        # 設定黑名單項目的過期時間與 token 相同
        # 這樣不會無限累積黑名單項目
        Rails.cache.write(
          "revoked_token:#{jti}",
          true,
          expires_in: expires_at - Time.current
        )
        
        render json: { message: '登出成功' }
      end
      
      def refresh
        # 使用 refresh token 獲取新的 access token
        refresh_token = params[:refresh_token]
        
        if refresh_token.present?
          begin
            decoded = JwtService.decode(refresh_token)
            user = User.find(decoded[:user_id])
            
            # 檢查 refresh token 類型
            unless decoded[:token_type] == 'refresh'
              raise TokenInvalidError, '無效的 token 類型'
            end
            
            # 生成新的 access token
            access_token = JwtService.encode(
              user_id: user.id,
              email: user.email,
              token_type: 'access'
            )
            
            render json: {
              access_token: access_token,
              expires_in: JwtService::ACCESS_TOKEN_EXPIRY
            }
          rescue StandardError => e
            render_unauthorized(e.message)
          end
        else
          render_unauthorized('缺少 refresh token')
        end
      end
      
      def me
        # 返回當前使用者資訊
        render json: UserSerializer.new(current_user).serializable_hash
      end
      
      private
      
      def generate_tokens(user)
        # 生成 access token 和 refresh token
        access_payload = {
          user_id: user.id,
          email: user.email,
          token_type: 'access'
        }
        
        refresh_payload = {
          user_id: user.id,
          token_type: 'refresh'
        }
        
        {
          access_token: JwtService.encode(access_payload),
          refresh_token: JwtService.encode(
            refresh_payload, 
            JwtService::REFRESH_TOKEN_EXPIRY.from_now
          )
        }
      end
      
      def render_login_success(user, tokens)
        render json: {
          user: UserSerializer.new(user).serializable_hash,
          access_token: tokens[:access_token],
          refresh_token: tokens[:refresh_token],
          expires_in: JwtService::ACCESS_TOKEN_EXPIRY
        }
      end
      
      def render_validation_errors(errors)
        render json: {
          error: '驗證失敗',
          details: errors.full_messages
        }, status: :unprocessable_entity
      end
      
      def user_params
        params.require(:user).permit(:email, :password, :name)
      end
    end
  end
end

四、實戰應用:LMS 系統案例

4.1 在 LMS 中的認證需求

我們的 LMS 系統有獨特的認證挑戰。不同於一般的應用,LMS 需要處理多種使用者角色和複雜的權限場景:

多重身份問題:同一個使用者可能在不同課程中有不同角色。Alice 可能是「Rails 入門」的學生,同時是「Ruby 基礎」的助教。

時效性控制:課程可能有開課和結課時間,認證需要考慮時間因素。

第三方整合:LMS 可能需要與企業的 SSO 系統整合,支援 SAML 或 OAuth。

讓我們實作一個能處理這些複雜需求的認證系統:

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  
  # 使用者在不同課程中的角色
  has_many :course_memberships
  has_many :courses, through: :course_memberships
  
  # 全域角色
  enum global_role: {
    student: 0,
    instructor: 1,
    admin: 2
  }
  
  # 認證相關的驗證
  validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, length: { minimum: 8 }, if: :password_required?
  
  # 智慧方法:檢查在特定課程中的角色
  def role_in_course(course)
    membership = course_memberships.find_by(course: course)
    membership&.role
  end
  
  def enrolled_in?(course)
    course_memberships.exists?(course: course)
  end
  
  def can_access_course?(course)
    # 管理員可以存取所有課程
    return true if admin?
    
    # 檢查是否註冊且在有效期內
    membership = course_memberships.find_by(course: course)
    return false unless membership
    
    # 檢查課程是否在開課期間
    course.active? && membership.active?
  end
  
  private
  
  def password_required?
    new_record? || password.present?
  end
end

# app/models/course_membership.rb
class CourseMembership < ApplicationRecord
  belongs_to :user
  belongs_to :course
  
  enum role: {
    student: 0,
    teaching_assistant: 1,
    instructor: 2
  }
  
  enum status: {
    active: 0,
    suspended: 1,
    completed: 2,
    dropped: 3
  }
  
  # 註冊時間和過期時間
  validates :enrolled_at, presence: true
  
  scope :active, -> { where(status: :active) }
  scope :current, -> { active.where('expires_at IS NULL OR expires_at > ?', Time.current) }
  
  def active?
    status == 'active' && (expires_at.nil? || expires_at > Time.current)
  end
end

4.2 擴展 JWT 支援課程權限

我們需要擴展 JWT 服務來支援課程相關的認證資訊:

# app/services/lms_jwt_service.rb
class LmsJwtService < JwtService
  class << self
    def encode_for_course(user, course)
      # 為特定課程生成 token
      # 這種 token 只能存取該課程的資源
      membership = user.course_memberships.find_by(course: course)
      
      raise TokenInvalidError, '使用者未註冊此課程' unless membership
      raise TokenInvalidError, '使用者在此課程的權限已失效' unless membership.active?
      
      payload = {
        user_id: user.id,
        course_id: course.id,
        course_role: membership.role,
        membership_id: membership.id,
        scope: 'course',
        expires_at: membership.expires_at
      }
      
      # 如果課程有結束時間,token 不應超過該時間
      exp = [
        ACCESS_TOKEN_EXPIRY.from_now,
        course.ends_at,
        membership.expires_at
      ].compact.min
      
      encode(payload, exp)
    end
    
    def encode_for_exam(user, exam)
      # 考試專用 token,時效性更短,權限更受限
      enrollment = user.course_memberships.find_by(course: exam.course)
      
      raise TokenInvalidError, '無權參加此考試' unless enrollment&.active?
      
      # 考試 token 的有效期就是考試時間
      payload = {
        user_id: user.id,
        exam_id: exam.id,
        scope: 'exam',
        started_at: Time.current,
        must_finish_by: exam.duration.from_now
      }
      
      encode(payload, exam.duration.from_now)
    end
  end
end

# app/controllers/api/v1/course_auth_controller.rb
module Api
  module V1
    class CourseAuthController < ApplicationController
      before_action :set_course
      
      def request_access
        # 請求存取特定課程的 token
        if current_user.can_access_course?(@course)
          token = LmsJwtService.encode_for_course(current_user, @course)
          
          render json: {
            course_token: token,
            course: CourseSerializer.new(@course).serializable_hash,
            role: current_user.role_in_course(@course),
            expires_in: JwtService::ACCESS_TOKEN_EXPIRY
          }
        else
          render json: {
            error: '無權存取此課程',
            enrollment_url: api_v1_course_enrollments_url(@course)
          }, status: :forbidden
        end
      end
      
      private
      
      def set_course
        @course = Course.find(params[:course_id])
      end
    end
  end
end

4.3 實作單一登入(SSO)支援

許多企業客戶要求 LMS 支援他們的 SSO 系統。我們可以擴展認證系統來支援這個需求:

# app/services/sso_service.rb
class SsoService
  def self.authenticate_via_saml(saml_response)
    # 解析 SAML 回應
    response = OneLogin::RubySaml::Response.new(saml_response)
    
    if response.is_valid?
      # 從 SAML 回應中提取使用者資訊
      email = response.nameid
      attributes = response.attributes
      
      # 尋找或建立使用者
      user = User.find_or_initialize_by(email: email)
      
      if user.new_record?
        # 首次 SSO 登入,建立帳號
        user.assign_attributes(
          name: attributes['name'],
          sso_provider: 'saml',
          sso_uid: response.nameid,
          # SSO 使用者不需要密碼
          password: SecureRandom.hex(32)
        )
        user.save!
      end
      
      # 更新 SSO 資訊
      user.update(
        last_sso_login_at: Time.current,
        sso_attributes: attributes.to_h
      )
      
      # 生成 JWT token
      JwtService.encode(
        user_id: user.id,
        email: user.email,
        auth_method: 'sso'
      )
    else
      raise TokenInvalidError, "SSO 認證失敗:#{response.errors.join(', ')}"
    end
  end
  
  def self.authenticate_via_oauth(provider, auth_hash)
    # 處理 OAuth 認證(Google, GitHub 等)
    user = User.find_or_initialize_by(
      sso_provider: provider,
      sso_uid: auth_hash['uid']
    )
    
    user.assign_attributes(
      email: auth_hash['info']['email'],
      name: auth_hash['info']['name'],
      avatar_url: auth_hash['info']['image']
    )
    
    user.password = SecureRandom.hex(32) if user.new_record?
    user.save!
    
    JwtService.encode(
      user_id: user.id,
      email: user.email,
      auth_method: "oauth_#{provider}"
    )
  end
end

五、深度思考:常見陷阱與最佳實踐

5.1 轉職者常見誤區

誤區 1:過度依賴 JWT 儲存資訊

來自 Node.js 背景的開發者常常想在 JWT 中塞入大量資訊,以減少資料庫查詢。這在 Express 的無狀態設計中很常見,但在 Rails 中,我們有更好的解決方案。

錯誤做法:

# 不要這樣做 - JWT 變得過大且包含敏感資訊
payload = {
  user_id: user.id,
  email: user.email,
  name: user.name,
  avatar_url: user.avatar_url,
  preferences: user.preferences,
  permissions: user.all_permissions,
  courses: user.courses.pluck(:id, :name)
}

正確做法:

# JWT 只包含識別資訊
payload = {
  user_id: user.id,
  email: user.email
}

# 使用 Rails 的快取機制儲存常用資訊
Rails.cache.fetch("user_context:#{user.id}", expires_in: 1.hour) do
  {
    name: user.name,
    avatar_url: user.avatar_url,
    preferences: user.preferences
  }
end

誤區 2:忽略 Rails 的安全機制

Spring Boot 開發者習慣了框架自動處理的安全性,可能會忽略 Rails API 模式下需要手動處理的部分。

需要注意的安全要點:

class ApplicationController < ActionController::API
  # API 模式下仍然需要 CORS 設定
  # 使用 rack-cors gem
  
  # 防止時序攻擊
  def secure_compare(a, b)
    return false unless a.bytesize == b.bytesize
    l = a.unpack("C*")
    r = b.unpack("C*")
    result = 0
    l.zip(r) { |x, y| result |= x ^ y }
    result == 0
  end
  
  # 限制請求頻率
  # 使用 rack-attack gem
end

5.2 效能與安全最佳實踐

Token 儲存策略

不同的儲存位置有不同的安全風險:

// 前端儲存策略參考

// 1. localStorage - 簡單但有 XSS 風險
localStorage.setItem('token', token);

// 2. httpOnly Cookie - 防止 XSS 但有 CSRF 風險
// 需要後端配合設定

// 3. 記憶體 + httpOnly Cookie 的混合方案(推薦)
// access token 在記憶體
// refresh token 在 httpOnly Cookie
class TokenManager {
  constructor() {
    this.accessToken = null;
  }
  
  setAccessToken(token) {
    this.accessToken = token;
  }
  
  getAccessToken() {
    return this.accessToken;
  }
  
  // refresh token 透過 httpOnly Cookie 自動傳送
}

實作 Rate Limiting

# config/initializers/rack_attack.rb
Rack::Attack.throttle('api/login', limit: 5, period: 1.minute) do |req|
  if req.path == '/api/v1/auth/login' && req.post?
    req.ip
  end
end

Rack::Attack.throttle('api/refresh', limit: 10, period: 1.hour) do |req|
  if req.path == '/api/v1/auth/refresh' && req.post?
    req.ip
  end
end

5.3 測試認證系統

測試是確保認證系統可靠的關鍵:

# spec/requests/api/v1/auth_spec.rb
require 'rails_helper'

RSpec.describe 'Api::V1::Auth', type: :request do
  describe 'POST /api/v1/auth/login' do
    let(:user) { create(:user, email: 'test@example.com', password: 'password123') }
    
    context '使用正確的認證資訊' do
      it '返回 JWT token' do
        post '/api/v1/auth/login', params: {
          email: 'test@example.com',
          password: 'password123'
        }
        
        expect(response).to have_http_status(:success)
        
        json = JSON.parse(response.body)
        expect(json['access_token']).to be_present
        expect(json['refresh_token']).to be_present
        
        # 驗證 token 可以被解碼
        decoded = JwtService.decode(json['access_token'])
        expect(decoded[:user_id]).to eq(user.id)
      end
    end
    
    context '使用錯誤的密碼' do
      it '返回未授權錯誤' do
        post '/api/v1/auth/login', params: {
          email: 'test@example.com',
          password: 'wrongpassword'
        }
        
        expect(response).to have_http_status(:unauthorized)
        expect(JSON.parse(response.body)['error']).to eq('電子郵件或密碼錯誤')
      end
    end
    
    context 'Token 過期處理' do
      it '自動重新整理過期的 token' do
        # 建立一個即將過期的 token
        expired_token = JwtService.encode(
          { user_id: user.id },
          1.second.from_now
        )
        
        sleep 2  # 等待 token 過期
        
        # 發送請求時附帶 refresh token
        refresh_token = JwtService.encode(
          { user_id: user.id, token_type: 'refresh' },
          7.days.from_now
        )
        
        get '/api/v1/auth/me', headers: {
          'Authorization' => "Bearer #{expired_token}",
          'X-Refresh-Token' => refresh_token
        }
        
        # 應該在 header 中返回新 token
        expect(response.headers['X-New-Token']).to be_present
      end
    end
  end
end

六、實踐練習:動手鞏固

6.1 基礎練習:建立簡單的 JWT 認證系統(預計 30 分鐘)

練習目標

建立一個基本的 Rails API 專案,實作 JWT 認證的核心功能。這個練習會幫助你理解認證系統的基本架構,並熟悉 Rails 中處理認證的模式。

步驟說明

步驟 1:建立新專案並安裝依賴

# 建立新的 Rails API 專案
rails new jwt_auth_practice --api --database=postgresql
cd jwt_auth_practice

# 在 Gemfile 中加入必要的 gem
# 編輯 Gemfile,加入以下內容:
# Gemfile
gem 'jwt'
gem 'bcrypt'
gem 'rack-cors'

group :development, :test do
  gem 'rspec-rails'
  gem 'factory_bot_rails'
end
# 安裝依賴
bundle install

# 設定資料庫
rails db:create

步驟 2:建立 User 模型

# 生成 User 模型
rails generate model User email:string:uniq password_digest:string name:string
rails db:migrate

步驟 3:設定 User 模型

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  
  validates :email, presence: true, 
                   uniqueness: { case_sensitive: false },
                   format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, length: { minimum: 6 }, if: :password_required?
  
  before_save :downcase_email
  
  private
  
  def downcase_email
    self.email = email.downcase
  end
  
  def password_required?
    password_digest.nil? || password.present?
  end
end

步驟 4:實作 JWT 服務

# app/services/json_web_token.rb
class JsonWebToken
  SECRET_KEY = Rails.application.credentials.secret_key_base || 'your-secret-key'
  
  def self.encode(payload, exp = 24.hours.from_now)
    payload[:exp] = exp.to_i
    JWT.encode(payload, SECRET_KEY)
  end
  
  def self.decode(token)
    decoded = JWT.decode(token, SECRET_KEY)[0]
    HashWithIndifferentAccess.new(decoded)
  rescue JWT::DecodeError => e
    raise ExceptionHandler::InvalidToken, e.message
  end
end

步驟 5:建立例外處理模組

# app/controllers/concerns/exception_handler.rb
module ExceptionHandler
  extend ActiveSupport::Concern
  
  class InvalidToken < StandardError; end
  class MissingToken < StandardError; end
  
  included do
    rescue_from ExceptionHandler::InvalidToken do |e|
      render json: { error: e.message }, status: :unauthorized
    end
    
    rescue_from ExceptionHandler::MissingToken do |e|
      render json: { error: e.message }, status: :unprocessable_entity
    end
    
    rescue_from ActiveRecord::RecordNotFound do |e|
      render json: { error: e.message }, status: :not_found
    end
  end
end

步驟 6:設定 ApplicationController

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include ExceptionHandler
  
  before_action :authorize_request
  attr_reader :current_user
  
  private
  
  def authorize_request
    header = request.headers['Authorization']
    header = header.split(' ').last if header
    
    begin
      decoded = JsonWebToken.decode(header)
      @current_user = User.find(decoded[:user_id])
    rescue ActiveRecord::RecordNotFound => e
      render json: { error: e.message }, status: :unauthorized
    rescue JWT::DecodeError => e
      render json: { error: e.message }, status: :unauthorized
    end
  end
end

步驟 7:實作認證控制器

# app/controllers/authentication_controller.rb
class AuthenticationController < ApplicationController
  skip_before_action :authorize_request, only: [:login, :register]
  
  def register
    user = User.new(user_params)
    if user.save
      token = JsonWebToken.encode(user_id: user.id)
      render json: { 
        token: token,
        user: { id: user.id, email: user.email, name: user.name }
      }, status: :created
    else
      render json: { errors: user.errors.full_messages }, 
             status: :unprocessable_entity
    end
  end
  
  def login
    user = User.find_by(email: params[:email])
    
    if user&.authenticate(params[:password])
      token = JsonWebToken.encode(user_id: user.id)
      render json: {
        token: token,
        user: { id: user.id, email: user.email, name: user.name }
      }, status: :ok
    else
      render json: { error: '無效的認證資訊' }, status: :unauthorized
    end
  end
  
  private
  
  def user_params
    params.permit(:email, :password, :name)
  end
end

步驟 8:設定路由

# config/routes.rb
Rails.application.routes.draw do
  post 'auth/register', to: 'authentication#register'
  post 'auth/login', to: 'authentication#login'
  
  # 測試用的受保護路由
  get 'profile', to: 'users#profile'
end

步驟 9:建立測試用的 UsersController

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def profile
    render json: { 
      user: {
        id: current_user.id,
        email: current_user.email,
        name: current_user.name
      }
    }
  end
end

測試驗證

使用 curl 或 Postman 測試你的 API:

# 1. 註冊新使用者
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "test@example.com",
    "password": "password123",
    "name": "Test User"
  }'

# 預期回應:
# {
#   "token": "eyJhbGciOiJIUzI1NiJ9...",
#   "user": {
#     "id": 1,
#     "email": "test@example.com",
#     "name": "Test User"
#   }
# }

# 2. 登入
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "test@example.com",
    "password": "password123"
  }'

# 3. 存取受保護的路由(使用上面獲得的 token)
curl http://localhost:3000/profile \
  -H "Authorization: Bearer YOUR_TOKEN_HERE"

# 預期回應:
# {
#   "user": {
#     "id": 1,
#     "email": "test@example.com",
#     "name": "Test User"
#   }
# }

常見問題與除錯

  1. 錯誤:uninitialized constant JsonWebToken

    • 確保 app/services/json_web_token.rb 檔案存在
    • 重啟 Rails 伺服器
  2. 錯誤:NoMethodError: undefined method 'authenticate'

    • 確認 User 模型有 has_secure_password
    • 確認資料表有 password_digest 欄位
  3. Token 解碼失敗

    • 檢查 SECRET_KEY 是否一致
    • 確認 token 格式正確(Bearer prefix)

6.2 進階挑戰:實作多角色認證系統(預計 1 小時)

挑戰目標

擴展基礎練習,加入多角色支援、refresh token 機制,以及 token 撤銷功能。這個挑戰會模擬 LMS 系統的真實需求。

實作步驟與解答

步驟 1:擴展 User 模型支援角色

# 建立遷移檔案
rails generate migration AddRoleToUsers role:integer
rails generate model Course name:string description:text
rails generate model Enrollment user:references course:references role:integer status:integer
rails db:migrate
# app/models/user.rb
class User < ApplicationRecord
  has_secure_password
  
  has_many :enrollments
  has_many :courses, through: :enrollments
  
  enum role: {
    student: 0,
    teacher: 1,
    admin: 2
  }
  
  # 既有的驗證...
  
  def role_for_course(course)
    enrollments.find_by(course: course)&.role || 'none'
  end
end

# app/models/enrollment.rb
class Enrollment < ApplicationRecord
  belongs_to :user
  belongs_to :course
  
  enum role: {
    student: 0,
    teaching_assistant: 1,
    instructor: 2
  }
  
  enum status: {
    active: 0,
    completed: 1,
    dropped: 2
  }
end

步驟 2:實作進階 JWT 服務

# app/services/jwt_token_service.rb
class JwtTokenService
  SECRET_KEY = Rails.application.credentials.secret_key_base
  
  class << self
    def generate_tokens(user)
      {
        access_token: encode_access_token(user),
        refresh_token: encode_refresh_token(user)
      }
    end
    
    def encode_access_token(user)
      payload = {
        user_id: user.id,
        email: user.email,
        role: user.role,
        exp: 15.minutes.from_now.to_i,
        iat: Time.current.to_i,
        jti: SecureRandom.uuid
      }
      JWT.encode(payload, SECRET_KEY, 'HS256')
    end
    
    def encode_refresh_token(user)
      payload = {
        user_id: user.id,
        exp: 7.days.from_now.to_i,
        iat: Time.current.to_i,
        jti: SecureRandom.uuid,
        token_type: 'refresh'
      }
      JWT.encode(payload, SECRET_KEY, 'HS256')
    end
    
    def decode(token)
      JWT.decode(token, SECRET_KEY, true, algorithm: 'HS256')[0]
    rescue JWT::ExpiredSignature
      raise TokenExpiredError
    rescue JWT::DecodeError
      raise TokenInvalidError
    end
    
    def refresh_access_token(refresh_token)
      payload = decode(refresh_token)
      
      unless payload['token_type'] == 'refresh'
        raise TokenInvalidError, 'Not a refresh token'
      end
      
      user = User.find(payload['user_id'])
      encode_access_token(user)
    rescue ActiveRecord::RecordNotFound
      raise TokenInvalidError, 'User not found'
    end
  end
end

class TokenExpiredError < StandardError; end
class TokenInvalidError < StandardError; end

步驟 3:實作 Token 黑名單(使用 Rails 快取)

# app/services/token_blacklist.rb
class TokenBlacklist
  def self.add(jti, exp)
    expires_in = Time.at(exp) - Time.current
    Rails.cache.write("blacklist:#{jti}", true, expires_in: expires_in)
  end
  
  def self.blacklisted?(jti)
    Rails.cache.exist?("blacklist:#{jti}")
  end
end

步驟 4:更新 ApplicationController

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  before_action :authenticate_request
  attr_reader :current_user, :current_token
  
  private
  
  def authenticate_request
    header = request.headers['Authorization']
    token = header.split(' ').last if header
    
    if token.nil?
      render json: { error: '缺少認證 token' }, status: :unauthorized
      return
    end
    
    begin
      @current_token = JwtTokenService.decode(token)
      
      # 檢查黑名單
      if TokenBlacklist.blacklisted?(@current_token['jti'])
        render json: { error: 'Token 已被撤銷' }, status: :unauthorized
        return
      end
      
      @current_user = User.find(@current_token['user_id'])
    rescue TokenExpiredError
      handle_expired_token
    rescue TokenInvalidError => e
      render json: { error: e.message }, status: :unauthorized
    rescue ActiveRecord::RecordNotFound
      render json: { error: '使用者不存在' }, status: :unauthorized
    end
  end
  
  def handle_expired_token
    refresh_token = request.headers['X-Refresh-Token']
    
    if refresh_token
      begin
        new_access_token = JwtTokenService.refresh_access_token(refresh_token)
        response.headers['X-New-Access-Token'] = new_access_token
        
        # 解碼新 token 並設定 current_user
        @current_token = JwtTokenService.decode(new_access_token)
        @current_user = User.find(@current_token['user_id'])
      rescue StandardError => e
        render json: { error: 'Token 重新整理失敗' }, status: :unauthorized
      end
    else
      render json: { error: 'Token 已過期' }, status: :unauthorized
    end
  end
  
  # 授權輔助方法
  def authorize_admin!
    unless current_user&.admin?
      render json: { error: '需要管理員權限' }, status: :forbidden
    end
  end
  
  def authorize_teacher!
    unless current_user&.teacher? || current_user&.admin?
      render json: { error: '需要教師權限' }, status: :forbidden
    end
  end
end

步驟 5:實作完整的認證控制器

# app/controllers/api/v1/auth_controller.rb
module Api
  module V1
    class AuthController < ApplicationController
      skip_before_action :authenticate_request, only: [:login, :register]
      
      def register
        user = User.new(registration_params)
        user.role = :student  # 預設角色
        
        if user.save
          tokens = JwtTokenService.generate_tokens(user)
          render json: {
            message: '註冊成功',
            user: user_response(user),
            access_token: tokens[:access_token],
            refresh_token: tokens[:refresh_token]
          }, status: :created
        else
          render json: { 
            errors: user.errors.full_messages 
          }, status: :unprocessable_entity
        end
      end
      
      def login
        user = User.find_by(email: params[:email]&.downcase)
        
        if user&.authenticate(params[:password])
          tokens = JwtTokenService.generate_tokens(user)
          
          render json: {
            message: '登入成功',
            user: user_response(user),
            access_token: tokens[:access_token],
            refresh_token: tokens[:refresh_token]
          }
        else
          render json: { 
            error: '電子郵件或密碼錯誤' 
          }, status: :unauthorized
        end
      end
      
      def logout
        # 將當前 token 加入黑名單
        jti = current_token['jti']
        exp = current_token['exp']
        
        TokenBlacklist.add(jti, exp)
        
        render json: { message: '登出成功' }
      end
      
      def refresh
        refresh_token = params[:refresh_token]
        
        if refresh_token.present?
          begin
            new_access_token = JwtTokenService.refresh_access_token(refresh_token)
            render json: {
              access_token: new_access_token
            }
          rescue StandardError => e
            render json: { 
              error: "Token 重新整理失敗: #{e.message}" 
            }, status: :unauthorized
          end
        else
          render json: { 
            error: '需要提供 refresh token' 
          }, status: :bad_request
        end
      end
      
      def me
        render json: {
          user: user_response(current_user),
          enrollments: current_user.enrollments.includes(:course).map do |e|
            {
              course_id: e.course_id,
              course_name: e.course.name,
              role: e.role,
              status: e.status
            }
          end
        }
      end
      
      private
      
      def registration_params
        params.permit(:email, :password, :name)
      end
      
      def user_response(user)
        {
          id: user.id,
          email: user.email,
          name: user.name,
          role: user.role
        }
      end
    end
  end
end

步驟 6:加入課程權限檢查

# app/controllers/api/v1/courses_controller.rb
module Api
  module V1
    class CoursesController < ApplicationController
      before_action :set_course, only: [:show, :update, :destroy]
      before_action :authorize_course_access!, only: [:show]
      before_action :authorize_course_instructor!, only: [:update, :destroy]
      
      def index
        courses = if current_user.admin?
                   Course.all
                 else
                   current_user.courses
                 end
                 
        render json: courses
      end
      
      def show
        render json: @course
      end
      
      def create
        authorize_teacher!
        
        course = Course.new(course_params)
        
        if course.save
          # 建立者自動成為講師
          Enrollment.create!(
            user: current_user,
            course: course,
            role: :instructor,
            status: :active
          )
          
          render json: course, status: :created
        else
          render json: { errors: course.errors.full_messages }, 
                 status: :unprocessable_entity
        end
      end
      
      def update
        if @course.update(course_params)
          render json: @course
        else
          render json: { errors: @course.errors.full_messages }, 
                 status: :unprocessable_entity
        end
      end
      
      def destroy
        @course.destroy
        render json: { message: '課程已刪除' }
      end
      
      private
      
      def set_course
        @course = Course.find(params[:id])
      end
      
      def course_params
        params.permit(:name, :description)
      end
      
      def authorize_course_access!
        unless current_user.admin? || current_user.enrolled_in?(@course)
          render json: { error: '無權存取此課程' }, status: :forbidden
        end
      end
      
      def authorize_course_instructor!
        enrollment = current_user.enrollments.find_by(course: @course)
        
        unless current_user.admin? || enrollment&.instructor?
          render json: { error: '需要講師權限' }, status: :forbidden
        end
      end
    end
  end
end

測試案例

# spec/requests/api/v1/auth_spec.rb
require 'rails_helper'

RSpec.describe 'Authentication', type: :request do
  let(:user) { create(:user, email: 'test@example.com', password: 'password123') }
  
  describe 'POST /api/v1/auth/login' do
    context 'with valid credentials' do
      it 'returns access and refresh tokens' do
        post '/api/v1/auth/login', params: {
          email: 'test@example.com',
          password: 'password123'
        }
        
        expect(response).to have_http_status(:success)
        json = JSON.parse(response.body)
        
        expect(json['access_token']).to be_present
        expect(json['refresh_token']).to be_present
        expect(json['user']['email']).to eq('test@example.com')
      end
    end
    
    context 'with invalid credentials' do
      it 'returns unauthorized error' do
        post '/api/v1/auth/login', params: {
          email: 'test@example.com',
          password: 'wrong_password'
        }
        
        expect(response).to have_http_status(:unauthorized)
        json = JSON.parse(response.body)
        expect(json['error']).to eq('電子郵件或密碼錯誤')
      end
    end
  end
  
  describe 'POST /api/v1/auth/logout' do
    it 'blacklists the current token' do
      tokens = JwtTokenService.generate_tokens(user)
      
      post '/api/v1/auth/logout', headers: {
        'Authorization' => "Bearer #{tokens[:access_token]}"
      }
      
      expect(response).to have_http_status(:success)
      
      # 嘗試使用已登出的 token
      get '/api/v1/auth/me', headers: {
        'Authorization' => "Bearer #{tokens[:access_token]}"
      }
      
      expect(response).to have_http_status(:unauthorized)
      json = JSON.parse(response.body)
      expect(json['error']).to eq('Token 已被撤銷')
    end
  end
  
  describe 'Token refresh' do
    it 'generates new access token with valid refresh token' do
      tokens = JwtTokenService.generate_tokens(user)
      
      post '/api/v1/auth/refresh', params: {
        refresh_token: tokens[:refresh_token]
      }
      
      expect(response).to have_http_status(:success)
      json = JSON.parse(response.body)
      expect(json['access_token']).to be_present
      
      # 新 token 應該可以使用
      get '/api/v1/auth/me', headers: {
        'Authorization' => "Bearer #{json['access_token']}"
      }
      
      expect(response).to have_http_status(:success)
    end
  end
end

驗證步驟

# 1. 執行測試
bundle exec rspec

# 2. 手動測試流程
# 註冊教師帳號
curl -X POST http://localhost:3000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "teacher@example.com",
    "password": "password123",
    "name": "Teacher User"
  }'

# 登入並獲取 tokens
curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "teacher@example.com",
    "password": "password123"
  }'

# 使用 access token 建立課程
curl -X POST http://localhost:3000/api/v1/courses \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Rails 入門",
    "description": "學習 Rails 基礎"
  }'

# 測試 token 重新整理
curl -X POST http://localhost:3000/api/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{
    "refresh_token": "YOUR_REFRESH_TOKEN"
  }'

# 登出(撤銷 token)
curl -X POST http://localhost:3000/api/v1/auth/logout \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

延伸思考

完成這個進階挑戰後,思考以下問題:

  1. 安全性改進:如何防止 refresh token 被濫用?考慮實作 refresh token rotation。

  2. 效能優化:當使用者數量增加時,如何優化 token 驗證的效能?

  3. 擴展性:如何支援第三方登入(OAuth)?需要修改哪些部分?

  4. 監控與稽核:如何追蹤異常的登入行為?考慮加入登入日誌和異常檢測。

這些練習和挑戰提供了從基礎到進階的完整學習路徑。基礎練習讓你熟悉 JWT 的核心概念,進階挑戰則模擬了真實專案的複雜需求。透過實作這些功能,你將深入理解 Rails 中認證系統的設計和實作細節。

七、知識連結:螺旋式深化

7.1 回顧與連結

我們今天建構的認證系統建立在前面幾天的基礎上:

Day 4 的 ActiveRecord 基礎:我們使用了 has_secure_password,這是 ActiveRecord 提供的密碼處理機制。理解模型層如何與認證整合是關鍵。

Day 6 的控制器模式before_action 的使用展示了 Rails 如何優雅地處理橫切關注點。認證是最典型的應用場景。

Day 8 的關聯設計:使用者與課程的多對多關係透過 course_memberships 實現,這種設計讓我們能靈活處理複雜的權限場景。

明天(Day 10)我們將深入授權系統,探討如何在認證的基礎上實作細粒度的權限控制。今天的 JWT payload 設計將直接影響明天的授權實作。

7.2 為後續內容鋪墊

今天實作的認證系統將在後續的學習中不斷被使用和擴展:

  • Day 15 ActionCable:WebSocket 連線需要特殊的認證機制
  • Day 16 檔案處理:私密檔案的存取需要驗證權限
  • Day 17 支付整合:支付前需要確認使用者身份
  • Day 27 AI 功能:API 呼叫需要追蹤使用量

八、總結:內化與展望

8.1 核心收穫

知識層面:
我們學會了如何從零實作 JWT 認證系統,理解了 token 的結構和安全機制,掌握了 Rails 中處理認證的慣例和模式。

思維層面:
理解了 Rails 為什麼不內建認證系統 — 這給了我們自由度去選擇最適合的方案。同時體會到 Rails 提供的基礎設施如何讓認證實作變得簡潔。

實踐層面:
能夠建構一個生產級的認證系統,處理 token 過期、自動重新整理、多角色支援等實際需求。這個系統將成為 LMS 的基石。

8.2 自我檢核清單

完成今天的學習後,你應該能夠:

  • [ ] 解釋 JWT 與 Session 認證的差異和適用場景
  • [ ] 實作完整的 JWT 認證流程
  • [ ] 處理 token 過期和重新整理
  • [ ] 設計支援多角色的認證系統
  • [ ] 整合第三方認證(SSO/OAuth)
  • [ ] 為認證系統撰寫測試

8.3 延伸資源

深入閱讀:

相關 Gem:

  • jwt:JWT 編碼和解碼
  • bcrypt:密碼加密(has_secure_password 依賴)
  • rack-cors:處理跨域請求
  • rack-attack:請求限流和防護

8.4 明日預告

明天我們將探討授權與權限管理。如果說今天學習的認證是確認「你是誰」,那明天就是判斷「你能做什麼」。我們將實作基於角色的存取控制(RBAC),處理 LMS 中複雜的權限場景。準備好深入權限的迷宮了嗎?讓我們繼續這段旅程。


上一篇
Day 8: ActiveRecord 進階關聯與查詢優化 - 用程式碼表達業務關係的藝術
下一篇
Day 10: 授權與權限管理 - 在 Rails 中實現精細的存取控制
系列文
30 天 Rails 新手村:從工作專案學會 Ruby on Rails13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言